Skip to content

[lexical-playground][lexical] Feature: Ruby annotation node with floating editor#8741

Merged
etrepum merged 34 commits into
facebook:mainfrom
mayrang:feat/7787-ruby-annotation
Jul 5, 2026
Merged

[lexical-playground][lexical] Feature: Ruby annotation node with floating editor#8741
etrepum merged 34 commits into
facebook:mainfrom
mayrang:feat/7787-ruby-annotation

Conversation

@mayrang

@mayrang mayrang commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a playground-level RubyNode (extends TextNode in token mode) with full keyboard navigation, IME composition support, and a floating annotation editor. The node renders as <ruby><rt> on export and imports the same structure back.

What changed

RubyNode (plugins/RubyExtension/RubyNode.ts) — a token-mode TextNode subclass with an annotation state (via createState). The DOM is a wrapper <span> containing an inner <span data-ruby-annotation="..."> so the annotation text renders via CSS ::before. exportDOM produces standard <ruby>...<rt>...</rt></ruby>.

RubyExtension (plugins/RubyExtension/index.ts) — registers arrow-key, backspace, and composition handlers. Arrow keys skip over consecutive ruby groups atomically — ruby base text is in token mode, so the cursor can't meaningfully stop on it (any keystroke would be redirected to a neighbor). Skipping the entire contiguous group avoids a confusing dead-stop. Shift+arrow extends selection past them. A SELECTION_CHANGE handler nudges collapsed cursors off non-composing ruby nodes to prevent the caret from landing inside a token.

Floating editor (plugins/RubyExtension/FloatingRubyEditor.tsx) — follows the FloatingLinkEditorPlugin pattern. The editor appears anchored to the selection when creating new ruby (toolbar trigger) or to the ruby DOM element when clicking an existing one. Enter confirms, Escape dismisses. While active, the FloatingTextFormatToolbar is suppressed so the two don't overlap.

Core changes (LexicalEvents.ts, LexicalUtils.ts) — targeted fixes for IME composition on token-mode TextNode subclasses:

  1. $onCompositionEndImpl now detects when composition ends on a token node and redirects the composed text to the adjacent TextNode via selection.insertText, instead of letting markDirty silently discard it. Uses the actual selection.anchor.offset to decide direction — offset 0 redirects to the previous sibling, offset=textLen to the next.
  2. $handleInput skips $shouldPreventDefaultAndInsertText during insertCompositionText on token nodes, so the browser's native composition UI stays intact until composition ends.
  3. $updateTextNodeFromDOMContent bails early when a composing token node's DOM is synced, preventing the reconciler from reverting mid-composition input.
  4. $handleCompositionStart now redirects composition away from token/segmented nodes by inserting COMPOSITION_START_CHAR, which triggers the existing insertText boundary logic to create an adjacent TextNode. This prevents composition from starting inside nodes that can't accept text modifications — the root cause of Bug: input Chinese character after mention break mention entity and repeat characters #6296 (mention entity destruction on CJK input). Combined with (1), this provides two layers: pre-composition redirect at start, and post-composition redirect at end as a fallback.

These core changes are generic — any token-mode or segmented TextNode subclass benefits from them, not just RubyNode.

Toolbar

The ルビ katakana text button is replaced with an SVG icon (ruby.svg) consistent with other toolbar icons. The button only activates when text is selected; clicking with an existing ruby in the selection removes it.

Closes #7787, #6296

Test plan

  • 47 unit tests (RubyNode.test.ts) — arrow skip, Shift+arrow selection, consecutive ruby group walk, line boundary fallback, backspace, guard conditions, HTML import rule.
  • 23 unit tests (RubyComposition.test.ts) — composition end redirect on token nodes, mid-composition DOM stability, composed text placement, edge cases (solo ruby, paragraph-first ruby, offset 0 redirect).
  • 22 e2e tests (Ruby.spec.mjs) across Chromium/Firefox/WebKit — insert, DOM structure, arrow navigation, backspace/delete, select-all, toggle off, copy-paste, JSON round-trip, exportDOM, Shift+arrow selection, line boundary navigation.
  • tsc --noEmit / prettier / eslint clean.
  • Manual playground (Chrome, Firefox, Safari on macOS) — all scenarios below pass on all three browsers:
Manual test scenarios (all three browsers)

Setup — paste this structure into each test:

document.addEventListener('copy', (e) => { e.preventDefault(); e.clipboardData.setData('text/html', '前<ruby>漢<rt>かん</rt></ruby><ruby>字<rt>じ</rt></ruby>後'); }, {once: true}); document.execCommand('copy');

Arrow navigation

  • 前|漢 → Right → 漢|字 (one move across ruby boundary)
  • 字|後 → Right → 後| (ruby → plain text)
  • |前 → Right → 前|漢 (plain text → ruby)
  • 漢|字 → Left → 前|漢 (ruby boundary, backward)
  • Shift+Right extends selection past ruby
  • Cmd+Right jumps to end of line

Typing at boundaries

  • 前|漢 (ruby start) → type → inserted outside ruby, after
  • 字|後 (ruby end) → type → inserted outside ruby, before

Backspace / Delete

  • 前|漢 → Backspace → deleted
  • 字|後 → Delete → deleted
  • Select ruby → Backspace → ruby node removed

Selection + delete

  • Drag-select 漢字 → Delete → both rubies deleted, 前後 remains
  • Drag-select 漢字後 → Delete → ruby + plain text deleted, remains
  • Cmd+A → Delete → all deleted, empty paragraph

Copy / paste

  • Select ruby → copy → paste → ruby structure preserved
  • External <ruby> HTML paste → converted to RubyNode
  • <rp> tags in pasted HTML → ignored correctly

Undo / Redo

  • Create ruby via toolbar → Cmd+Z → ruby unwrapped
  • Cmd+Z → Cmd+Shift+Z → ruby restored

Ruby create / remove

  • Select text → ruby toolbar icon → floating editor → type annotation → Enter → ruby created
  • Click existing ruby → floating editor → edit annotation → Enter → annotation updated
  • Click existing ruby → trash button → unwrapped to plain text
  • No selection → ruby toolbar icon → nothing happens

Serialization

  • JSON export contains type: "ruby", annotation: "かん", text: "漢"
  • JSON round-trip (export → parseEditorState → setEditorState) → no visual change
  • HTML export produces <ruby>漢<rt>かん</rt></ruby>

Edge cases

  • Paragraph-start ruby: Left at |漢 → no movement
  • Paragraph-end ruby: Right at 字| → no movement
  • Ruby-only paragraph: select → delete → empty paragraph remains
  • Three consecutive rubies: each boundary navigable in one arrow press

Design notes

Package placement: RubyNode lives in lexical-playground, not in a dedicated @lexical/ruby package. The node and extension are structured for extraction if there's interest — RubyNode has no playground dependencies, and RubyExtension only depends on @lexical/html for the import rule.

Token-mode TextNode vs. inline ElementNode: The original design was an inline ElementNode subclass (like LinkNode) with the base text as a child TextNode. During implementation this turned out to be impractical for several reasons:

  • Cursor enters the element freely, but ruby base text shouldn't be directly editable — changing 漢 to something else while keeping annotation かん doesn't make sense as a default editing model. Token mode gives this atomicity without extra cursor-trapping logic.
  • IME composition on a child TextNode inside an inline ElementNode creates a cascade of issues with Lexical's reconciler and browser selection normalization at element boundaries. The reconciler tries to restore DOM state mid-composition, and Safari in particular normalizes the cursor back into the element across compositionstart / compositionend. With token mode, composition is handled by the core token-redirect path (which this PR fixes) rather than fighting per-element boundary conditions.
  • Selection across multiple ruby elements produces partial-element selections that need extensive normalization. Token mode treats each ruby as an atomic point in the selection, matching how <ruby> behaves in HTML — base text and annotation are a single unit.

The tradeoff is that formatting the base text independently (bold only on the kanji) isn't possible without unwrapping, but this matches standard <ruby> semantics.

Composition visual limitation (Safari): When IME composition starts immediately after a ruby node, Safari normalizes the cursor into the preceding ruby's inner <span> rather than the adjacent TextNode. The composed text therefore appears inside the ruby DOM. Since the annotation is rendered via ::after on the same element, showing both the annotation and the composing text in the same space would overlap. The extension detects this via compositionstart / compositionupdate listeners and adds a --composing CSS class that hides the ::after annotation until compositionend. The result is that the previous ruby's furigana temporarily disappears during the first character's composition, then reappears once composition ends and the text is redirected to its own TextNode.

We tried several approaches to avoid this:

  • Moving the cursor to the adjacent TextNode programmatically before composition: Safari re-normalizes it back into the ruby span, undoing the move.
  • Inserting a ZWSP gap TextNode between the ruby and the next node to give Safari a landing target: the gap triggers $normalizeTextNode which merges it into the adjacent text, and if guarded with toggleUnmergeable(), a transform loop removes and re-creates it indefinitely.
  • Modifying DOM during compositionstart: breaks the IME state entirely, producing garbled input.

Hiding the annotation during composition was the least-bad option — functional correctness is preserved, and the visual disruption lasts only for the first composed character.

@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview Jul 4, 2026 11:53pm
lexical-playground Ready Ready Preview Jul 4, 2026 11:53pm

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 23, 2026

@potatowagon potatowagon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by Navi (Tater Thoughts Bobblehead) on behalf of @potatowagon.

Assessment: Needs fixes before merge (CI failing) — but the feature design is solid.

What this PR does

Major new feature: Ruby annotation node with floating editor for the playground. Ruby annotations (<ruby>漢<rt>かん</rt></ruby>) are essential for CJK text to show pronunciation guides (furigana/pinyin). Implementation includes:

  • RubyNode (nodes/RubyNode.ts): TextNode subclass in token mode with $config() protocol, annotation state via createState, dual DOM representation (wrapper span > inner span with data-ruby-annotation CSS annotation via ::after), semantic exportDOM producing <ruby>/<rt>.
  • RubyExtension (plugins/RubyExtension/index.ts): Comprehensive extension handling arrow key navigation (skips ruby groups atomically), Shift+arrow selection extension, backspace deletion, $nudgeOffRuby for selection normalization, Safari IME composition guards, and DOMImportExtension for <ruby> HTML import.
  • FloatingRubyEditorPlugin: Floating UI editor (annotation input) triggered via toolbar button or click on existing ruby node.
  • Core Lexical changes (LexicalEvents.ts, LexicalUtils.ts): Modifies $handleInput, $handleCompositionStart, $onCompositionEndImpl, and $updateTextNodeFromDOMContent to support composition on token nodes — redirecting composed text to adjacent TextNodes instead of mutating the token.

What I checked

  1. Architecture: Token-mode TextNode with wrapper DOM and DOMSlot is the right pattern for inline decorators that carry text. The $config() protocol usage is correct.
  2. Arrow navigation: The $skipRubyOnArrow logic correctly walks consecutive ruby groups and handles boundary conditions (ruby as first/last/only child → moves to parent element point).
  3. Composition handling: The Safari IME composition guard ($nudgeOffRuby skips when composing, $updateTextNodeFromDOMContent early-returns for composing tokens) is critical — prevents data loss during CJK input.
  4. Core changes: The modifications to $onCompositionEndImpl (now returns boolean to indicate token-redirect) and $handleInput (new isCompositionOnToken guard) are well-scoped. The token redirect at composition-end correctly uses selection.insertText(data) which handles the token boundary insertion natively.
  5. Test coverage: Excellent — 764-line e2e spec (20+ tests), 1395-line unit test, 534-line composition test. Covers arrow skip, shift+arrow, backspace, copy/paste, toggle on/off, serialization, exportDOM, consecutive rubies, boundary cases, guard conditions.

CI Status — ❌ TWO FAILURES

  1. Integrity (lint): no-shadow error in RubyExtension/index.ts line 229 — variable sel shadows the sel import from @lexical/html. Easy fix: rename the local variable (e.g., domSel or nativeSel).

  2. E2e tests (11 failures): Multiple Ruby e2e tests failing with assertion errors — "Ruby DOM has wrapper span with inner annotated span", "Arrow left skips over ruby node", etc. These are the PR's own new tests failing, suggesting the floating editor or DOM structure in the real browser environment differs slightly from what the tests expect. Needs investigation.

www compatibility

No www compat concerns — all playground-only changes except the core LexicalEvents.ts / LexicalUtils.ts modifications. The core changes are additive (new code paths for token+composing) and don't alter existing behavior for non-token nodes. The $onCompositionEndImpl return type change from void to boolean is internal (not exported).

Suggestions

  • Fix the lint no-shadow error (rename local sel to nativeSel on line 229 of RubyExtension/index.ts)
  • Investigate the 11 e2e test failures — likely a timing or DOM structure mismatch
  • Consider whether the core LexicalEvents.ts changes should be in a separate PR for easier review/rollback (they're the highest-risk part)

Overall excellent work — comprehensive feature with thorough test coverage and proper IME handling. Just needs the CI fixes.

@mayrang

mayrang commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

IME work is always a handful, but this ruby one was something else 😂

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't had a chance to take a close look at the code yet but the functionality here is really cool! Seems to work well, was surprised that copy and paste of ruby html from wikipedia worked perfectly.

mayrang added 2 commits July 3, 2026 23:53
- Copy format/style from source text when creating ruby via $toggleRuby
- Add role="group" and aria-label to ruby DOM wrapper for screen readers
- Add aria-labels to floating editor input and buttons
- Add Enter/Space keyboard activation to floating editor buttons
When IME composition starts on a token or segmented TextNode (e.g.
MentionNode, RubyNode), the browser writes composed text directly
into the node's DOM — breaking segmented nodes (entity destruction)
and losing input on token nodes (revert on compositionEnd).

Add a guard in $handleCompositionStart so that composition on these
restricted nodes triggers COMPOSITION_START_CHAR insertion, which
creates an adjacent TextNode via the existing insertText boundary
logic and redirects composition there. Fixes facebook#6296.

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this works great, but the tests have some bad practices and the playground could be configured a bit more cleanly

import {MentionNode} from './MentionNode';
import {PageBreakNode} from './PageBreakNode';
import {PollNode} from './PollNode';
import {RubyNode} from './RubyNode';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There isn't any reason to update this file for nodes that are configured by extensions

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — removed RubyNode from PlaygroundNodes.

Comment on lines +105 to +108
editor = createTestEditor({nodes: [RubyNode]});
registerRichText(editor);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
extensionCleanup = (RubyExtension as any).register(editor);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should really just be using the extension instead of doing weird stuff like this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — both test files now use buildEditorFromExtensions(RubyExtension).

Comment on lines +56 to +57
editor = createTestEditor({nodes: [RubyNode]});
registerRichText(editor);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these tests should also be using the extension

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — same as above.

Comment on lines +32 to +38
export type SerializedRubyNode = Spread<
{
annotation: string;
},
SerializedTextNode
>;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These serialization types aren't very useful in modern lexical, we don't need to define them. It can be entirely inferred by typescript. Also it's wrong because annotation is actually optional, won't be serialized if it's the default value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — removed the type entirely.

});
}

constructor(text: string = '', key?: import('lexical').NodeKey) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type NodeKey should simply be imported, or you can eliminate the custom constructor entirely by having $createRubyNode do setMode('token')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — eliminated the custom constructor. $createRubyNode uses $create(RubyNode).setMode('token').setAnnotation(annotation).

editor: LexicalEditor,
target: EventTarget | null,
): string | null {
if (!(target instanceof HTMLElement)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!(target instanceof HTMLElement)) {
if (!isHTMLElement(target)) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment on lines +237 to +240
editorElement.addEventListener('focusout', handleBlur);
return () => {
editorElement.removeEventListener('focusout', handleBlur);
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
editorElement.addEventListener('focusout', handleBlur);
return () => {
editorElement.removeEventListener('focusout', handleBlur);
};
return registerEventListener(editorElement, 'focusout', handleBlur);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment on lines +260 to +293
if (prevRootElement) {
prevRootElement.removeEventListener(
'compositionstart',
checkCompositionInRuby,
true,
);
prevRootElement.removeEventListener(
'compositionupdate',
checkCompositionInRuby,
true,
);
prevRootElement.removeEventListener(
'compositionend',
onCompositionEnd,
true,
);
}
if (rootElement) {
rootElement.addEventListener(
'compositionstart',
checkCompositionInRuby,
true,
);
rootElement.addEventListener(
'compositionupdate',
checkCompositionInRuby,
true,
);
rootElement.addEventListener(
'compositionend',
onCompositionEnd,
true,
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (prevRootElement) {
prevRootElement.removeEventListener(
'compositionstart',
checkCompositionInRuby,
true,
);
prevRootElement.removeEventListener(
'compositionupdate',
checkCompositionInRuby,
true,
);
prevRootElement.removeEventListener(
'compositionend',
onCompositionEnd,
true,
);
}
if (rootElement) {
rootElement.addEventListener(
'compositionstart',
checkCompositionInRuby,
true,
);
rootElement.addEventListener(
'compositionupdate',
checkCompositionInRuby,
true,
);
rootElement.addEventListener(
'compositionend',
onCompositionEnd,
true,
);
}
if (rootElement) {
return registerEventListeners(
rootElement,
{
compositionend: onCompositionEnd,
compositionstart: checkCompositionInRuby,
compositionupdate: checkCompositionInRuby,
},
true,
);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread packages/lexical-playground/src/App.tsx Outdated
DragDropPasteExtension,
EmojisExtension,
MentionsExtension,
RubyExtension,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be in PlaygroundRichTextExtension because it doesn't really work unless it's rich text

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread packages/lexical/src/LexicalEvents.ts Outdated
Comment on lines +1466 to +1468
selection.anchor.set(compositionKey, offset, 'text');
selection.focus.set(compositionKey, offset, 'text');
selection.insertText(data);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
selection.anchor.set(compositionKey, offset, 'text');
selection.focus.set(compositionKey, offset, 'text');
selection.insertText(data);
node.select(offset, offset).insertText(data);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

mayrang added 4 commits July 4, 2026 18:08
… updateDOM

- Move RubyNode and FloatingRubyEditor into RubyExtension folder
- Remove RubyNode from PlaygroundNodes (extension handles registration)
- Move RubyExtension to PlaygroundRichTextExtension (rich-text only)
- Replace custom constructor with $create(RubyNode).setMode('token')
- Fix updateDOM: use $getStateChange + prevNode.__text (getters defer to latest)
- Add version?: NodeStateVersion param to getAnnotation
- Delete SerializedRubyNode type (TypeScript infers it)
- Use isHTMLElement, registerEventListener, registerEventListeners utils
- Convert tests to buildEditorFromExtensions pattern
- Simplify LexicalEvents token redirect: node.select(offset, offset).insertText(data)
…click handling

- Skip keydown during IME composition (isComposing guard) to prevent
  composed text from leaking into the contenteditable on Enter
- Replace manual DOM walk (getRubyNodeKeyFromDOM) with $getNearestNodeFromDOMNode
- Add isEditorPointerDownRef to suppress focusout during pointer interaction
- Defer focusout close via rAF when relatedTarget is null (Firefox)
- Guard $nudgeOffRuby with isMouseDown flag (mouseup on document)
- Use requestAnimationFrame for editor.focus() after submit/delete
- Fix button sizing and icon alignment in floating editor CSS
- Use mask-image for ruby toolbar icon
return;
}
requestAnimationFrame(() => {
if (editorElement.contains(document.activeElement)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these document references correct given the possibility of iframes and shadow roots?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, they weren't — switched all three document references to ownerDocument for iframe/shadow root compatibility.

The editor.read() call inside the CLICK_COMMAND handler (which already
runs inside editor.update()) prevented downstream handlers from
establishing NodeSelection on decorator clicks (images, cards, etc.).

Also replace document.activeElement with ownerDocument.activeElement
for iframe/shadow root compatibility.
@mayrang

mayrang commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

All feedback addressed — ran into some issues along the way so it took a bit longer than expected 😅

  • Colocated RubyNode and FloatingRubyEditor into the extension folder
  • Removed SerializedRubyNode type, custom constructor, and redundant $transform
  • Fixed updateDOM to use $getStateChange + prevNode.__text
  • Added version parameter to getAnnotation
  • Converted both test files to buildEditorFromExtensions pattern
  • Applied all utility suggestions (isHTMLElement, registerEventListener, registerEventListeners, node.select())
  • Moved RubyExtension to PlaygroundRichTextExtension
  • Fixed document references to use ownerDocument for iframe/shadow root compatibility

claude and others added 2 commits July 4, 2026 23:46
…on + shadow-aware floating editor focus

Rewrites RubyExtension's arrow navigation on the NodeCaret APIs:
$walkPastRubyChain's hand-rolled direction lambdas become $getSiblingCaret /
getNodeAtCaret over a CaretDirection, and $skipRubyOnArrow's duplicated
edge-detection and destination branches collapse into a single path:
$caretFromPoint + $isExtendableTextPointCaret detect a point at the edge of
a position adjacent to a ruby (now including element points, which
previously fell through to native handling), and the landing is one
$setPointFromCaret on the flipped edge caret — the same boundary
materialized off the token node (the near edge of the adjacent text node,
or the parent element point when there is no sibling). The backspace
handler uses the same caret primitives, generalizing the anchor-offset-0
check to element points such as the parent-boundary position that arrow
navigation itself creates, and the two arrow command registrations are
deduplicated into a single factory over [command, direction] pairs.

The offset>=1 landing for shift-extension forward from a caret on the
ruby is preserved, and its comment corrected: it is not Safari-specific.
A focus at offset 0 of the text node after the ruby is resolved back
onto the ruby end by the DOM selection round-trip (reproduced in
Chromium), so without it every further Shift+Right re-lands at the same
boundary and the selection stops growing.

In FloatingRubyEditor, ownerDocument.activeElement fixes the
cross-document (iframe) case but still reports the shadow host when the
editor UI is rendered inside a shadow root, so the focusout fallback
closed the popup while its input still had focus. Both focus checks now
use getActiveElement (the DocumentOrShadowRoot-scoped active element,
same pattern as FloatingLinkEditorPlugin).

Also extracts $unwrapRubyNode (replace a ruby with an equivalent plain
TextNode preserving format/style), previously duplicated across
$toggleRuby, $unwrapRubiesInSelection, and handleDelete.

New coverage for behavior this change introduces or relies on:
- unit: element-point arrow handling (skips a chain the point faces;
  falls through to native when the chain is behind the point, so the
  caret can leave the paragraph) and element-point backspace; dispatched
  inside the update because a committed element point can be re-resolved
  onto the adjacent text node by the DOM selection round-trip
- e2e: repeated Shift+Right across a ruby must keep extending the
  selection (fails without the offset>=1 landing, in Chromium too)
- e2e (isShadowDOM mode): a focusout with no relatedTarget while the
  popup's input has focus must not close the floating ruby editor
  (fails against document/ownerDocument-based activeElement checks),
  and clicking back into the editor still closes it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RLAwZEC8LrpGQ9LzPdcH2
@etrepum

etrepum commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

@mayrang I worked with claude to simplify some of the code to use the caret APIs and add a little more test coverage, let me know if you think something else should be addressed before merge

Suggested changes: `claude/ruby-extension-nodecaret-h1k1if` (1 commit on top of `0a8698d`)

RubyExtension: rewrite arrow navigation on NodeCaret APIs

  • $walkPastRubyChain's direction lambdas → $getSiblingCaret/getNodeAtCaret over a CaretDirection
  • $skipRubyOnArrow's duplicated edge-detection/destination branches collapse to one path:
    $caretFromPoint + $isExtendableTextPointCaret for detection, and a single
    $setPointFromCaret(point, edgeCaret.getFlipped()) for the landing (same boundary,
    materialized off the token node — text points on the ruby would re-enter the handler
    and route IME through the token path; ~21 existing unit tests pin the representation)
  • Element points now handled uniformly (previously bailed on point.type !== 'text');
    backspace uses the same primitives and works at the parent-boundary element point
    that arrow navigation itself creates; left/right registrations deduplicated
  • The offset >= 1 shift-landing is kept, comment corrected: it is not Safari-specific —
    a focus at offset 0 after the ruby is resolved back onto the ruby end by the DOM
    selection round-trip (reproduced in Chromium), stalling repeated Shift+Right

FloatingRubyEditor: shadow-safe focus checks

  • ownerDocument.activeElement fixes iframes but reports the shadow host inside a
    shadow root, so the focusout fallback closed the popup while its input had focus.
    Both checks now use getActiveElement (same pattern as FloatingLinkEditorPlugin)

Dedup

  • Extracted $unwrapRubyNode; removes 3 copies of the ruby→TextNode unwrap logic

Tests added

  • Unit (6): element-point arrows (incl. must-not-consume at boundaries so the caret can
    leave the paragraph) and element-point backspace
  • E2E: repeated Shift+Right stall guard (fails without the offset≥1 landing);
    2 shadow-DOM floating-editor tests (the focusout one fails against 0a8698d)

Verification

  • 77/77 unit; 25/25 e2e on Chromium, Firefox, and WebKit; tsc/ESLint/Prettier clean
  • Both behavioral fixes proven differentially: each new guard test fails on 0a8698d

@mayrang

mayrang commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Reviewed and tested the NodeCaret refactor + shadow-aware focus commit locally — unit tests pass, tsc clean. LGTM, ready to merge whenever you are.

@etrepum etrepum added this pull request to the merge queue Jul 5, 2026
Merged via the queue into facebook:main with commit 1695c4c Jul 5, 2026
46 checks passed
@etrepum etrepum mentioned this pull request Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: support of ruby characters

4 participants